读《webpack 中的 watch & cache》(上)

一、前言

在现代的单页应用开发中,webpack 已经成为不可缺失的一部分,而 webpack-dev-server 给我们开发与调试带来了很大的便利。其中最有特点的部分就是 proxy 与 HMR(Hot Module Replacement)。

  • HMR :对代码修改并保存后,webpack将会对代码进行打包,并将新的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,以实现在不刷新浏览器的前提下更新页面。
  • proxy:更多的时候被我们用来代理我们的服务,主要是解决跨域的问题。

二、webpack 之 –watch

在开始了解 webpack-dev-server 之前,我们可以先了解一下 watch 是什么。

1.1 什么是 watch 服务

跟普通的 webpack 不带参数的执行方式的区别:

1
2
3
4
5
6
7
8
9
10
11
if (firstOptions.watch || options.watch) {
...

compiler.watch(watchOptions, compilerCallback);// watch 方式执行

...
} else {
compiler.run((err, stats) => { // 编译方式执行
...
});
}

在创建 compiler 实例之后,判断是否存在 watch 参数,如果存在则以 watch 方式执行。而不是走 compuler.run 进入编译阶段。

1.2 watch 服务做了什么

1.2.1 在 watch 服务中会生成 watching 实例来接管具体的编译流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Watching {
constructor(compiler, watchOptions, handler) {
this.startTime = null;// 执行每次编译时会赋值编译启动时间
this.invalid = false;
this.handler = handler;
this.callbacks = [];
this.closed = false;
this.suspended = false;
if (typeof watchOptions === "number") {
this.watchOptions = {
aggregateTimeout: watchOptions
};
} else if (watchOptions && typeof watchOptions === "object") {
this.watchOptions = Object.assign({}, watchOptions);
} else {
this.watchOptions = {};
}
this.watchOptions.aggregateTimeout =
this.watchOptions.aggregateTimeout || 200;
this.compiler = compiler;// compiler 实例
this.running = true;
this.compiler.readRecords(err => {
if (err) return this._done(err);

this._go();// 进入编译
});
}
_go(){
...
}
_done(){
...
}
}

在 Watching 实例上记录了编译时启动时间,运行状态,compiler 实例以及回调等。

1.2.2 执行编译

无论是 watch 模式还是编译模式,他们都是调用 compiler 实例上的 compile 方法,传入的回调函数不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
if (err) return callback(err);

this.hooks.compile.call(params);

const compilation = this.newCompilation(params);// 创建 compilation 实例

this.hooks.make.callAsync(compilation, err => {// 模块解析,加载 loader,loader 对文件处理,寻找依赖阶段
if (err) return callback(err);

compilation.finish(err => {
if (err) return callback(err);

compilation.seal(err => { // 生成输出资源阶段,存在 compilation 里
if (err) return callback(err);

this.hooks.afterCompile.callAsync(compilation, err => {
if (err) return callback(err);

return callback(null, compilation);
});
});
});
});
});
}

编译模式的回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const onCompiled = (err, compilation) => {
if (err) return finalCallback(err);

if (this.hooks.shouldEmit.call(compilation) === false) {
const stats = new Stats(compilation);
stats.startTime = startTime;
stats.endTime = Date.now();
this.hooks.done.callAsync(stats, err => {
if (err) return finalCallback(err);
return finalCallback(null, stats);
});
return;
}

this.emitAssets(compilation, err => {// 资源输出
if (err) return finalCallback(err);

if (compilation.hooks.needAdditionalPass.call()) {
compilation.needAdditionalPass = true;

const stats = new Stats(compilation);
stats.startTime = startTime;
stats.endTime = Date.now();
this.hooks.done.callAsync(stats, err => {
if (err) return finalCallback(err);

this.hooks.additionalPass.callAsync(err => {
if (err) return finalCallback(err);
this.compile(onCompiled);
});
});
return;
}

this.emitRecords(err => {// 记录输出
if (err) return finalCallback(err);

const stats = new Stats(compilation);
stats.startTime = startTime;
stats.endTime = Date.now();
this.hooks.done.callAsync(stats, err => {
if (err) return finalCallback(err);
return finalCallback(null, stats);
});
});
});
};

watch 模式的回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const onCompiled = (err, compilation) => {
if (err) return this._done(err);
if (this.invalid) return this._done();

if (this.compiler.hooks.shouldEmit.call(compilation) === false) {
return this._done(null, compilation);
}

this.compiler.emitAssets(compilation, err => {// 资源输出
if (err) return this._done(err);
if (this.invalid) return this._done();
this.compiler.emitRecords(err => {// 记录输出
if (err) return this._done(err);

if (compilation.hooks.needAdditionalPass.call()) {
compilation.needAdditionalPass = true;

const stats = new Stats(compilation);// 包含构建的开始结束时间
stats.startTime = this.startTime;
stats.endTime = Date.now();
this.compiler.hooks.done.callAsync(stats, err => {
if (err) return this._done(err);

this.compiler.hooks.additionalPass.callAsync(err => {
if (err) return this._done(err);
this.compiler.compile(onCompiled);
});
});
return;
}
return this._done(null, compilation);// 在完成这一次编译之后,调用 _done 方法进行 watch 的最后一步
});
});
};

watch 模式也是走之前的那套流程,解析并得到loader、文件路径绝对路径。加载 loader,loader 对文件处理。解析文件内容寻找依赖文件,重复加载与解析的过程。最后生成输出文件资源。

1.2.3 调用文件监听

在上述流程走完之后就进入主题,从 _done 方法开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
_done(err, compilation) {
this.running = false;// 改变状态

...

const stats = compilation ? this._getStats(compilation) : null;// 记录了开始、结束时间和hash
if (err) {
this.compiler.hooks.failed.call(err);
this.handler(err, stats);
return;
}

this.compiler.hooks.done.callAsync(stats, () => {
this.handler(null, stats);
if (!this.closed) {
this.watch(// 文件变化监听
Array.from(compilation.fileDependencies),
Array.from(compilation.contextDependencies),
Array.from(compilation.missingDependencies)
);
}
for (const cb of this.callbacks) cb();
this.callbacks.length = 0;
});
}

调用 watch 方法监听文件变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
watch(files, dirs, missing) {
this.pausedWatcher = null;
this.watcher = this.compiler.watchFileSystem.watch(
files,
dirs,
missing,
this.startTime,
this.watchOptions,
(
err,
filesModified,
contextModified,
missingModified,
fileTimestamps,
contextTimestamps,
removedFiles
) => {
this.pausedWatcher = this.watcher;
this.watcher = null;
if (err) {
return this.handler(err);
}
this.compiler.fileTimestamps = fileTimestamps;
this.compiler.contextTimestamps = contextTimestamps;
this.compiler.removedFiles = removedFiles;
if (!this.suspended) {
this._invalidate();
}
},
(fileName, changeTime) => {
this.compiler.hooks.invalid.call(fileName, changeTime);
}
);
}

可以看到 watch 方法通过 compiler.watchFileSystem 的 watch 方法实现,可以大致看出在文件(夹)变化触发编译后,会执行传递的回调函数,最终会调用 _invalidate 方法进行编译触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_invalidate() {
if (this.watcher) {
this.pausedWatcher = this.watcher;
this.watcher.pause();
this.watcher = null;
}

if (this.running) {
this.invalid = true;
return false;
} else {
this._go();
}
}

从上可知,watch 服务的流程主要就是重复的调用 _go -> _done -> _invalidate 这几个方法。也就是 编译 -> watch监听编译 -> 文件变更触发编译 -> 编译 的循环。

三、chokidar

在开始讲 watchFileSystem 之前,先了解一下 chokidar

1.1 chokidar 是什么

chokidar 是对 fs.watch 和 fs.watchFile 的封装,它还是依赖于 NodeJS 核心的 fs 模块,但是它比 fs.watch 和 fs.watchFile 更高效,并且拥有更好的性能。

1.1.1 chokidar 的方法和事件

chokidar.watch() 调用后会产生一个 FSWatcher 的实例,FSWatcher 拥有的方法:

  • add(path / paths):文件(夹)或全局模式监听,接收一个字符串数组或者字符串。
  • on(event, callback):监听 FS 事件,事件包括: add, addDir, change, unlink, unlinkDir, ready, raw, error
  • unwatch(path / paths):停止文件(夹)或全局模式监听,接收一个字符串数组或者字符串。
  • close: 移除所有监听,返回 Promise
  • getWatched:返回一个对象,该对象表示此 FSWatcher 实例正在监视的文件系统上的所有路径。 对象的键是所有目录(除非使用cwd选项,否则使用绝对路径),并且值是每个目录中包含的项目名称的数组。

1.1.2 chokidar 的简单使用姿势

当文件变动的时候触发回调函数

1
2
3
4
const chokidar = require('chokidar');

chokidar.watch('./test2.js')
.on('change', path => console.log(`File ${path} has been changed`))

四、 watchFileSystem

1.1 初始化 watchFileSystem

其实是在创建 compiler 实例的时候,之前说过初始化 compiler 之中有 new Compiler 创建实例,有遍历插件数组,调用插件的 apply 方法,而 watchFileSystem 是通过 new NodeEnvironmentPlugin 初始化的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
compiler = new Compiler(options.context);// 创建 compiler 实例
compiler.options = options;
new NodeEnvironmentPlugin({// 初始化 watchFileSystem
infrastructureLogging: options.infrastructureLogging
}).apply(compiler);
if (options.plugins && Array.isArray(options.plugins)) {
for (const plugin of options.plugins) {// 遍历插件数组,在 compiler 钩子上挂载回调
if (typeof plugin === "function") {
plugin.call(compiler, compiler);
} else {
plugin.apply(compiler);
}
}
}

初始化的方式,跟包含 apply 方法的对象是一样的。这边先初始化得到 NodeEnvironmentPlugin 对象实例,再调用 apply 方法往 compuler 上挂载属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class NodeEnvironmentPlugin {
constructor(options) {
this.options = options || {};
}

apply(compiler) {
compiler.infrastructureLogger = createConsoleLogger(
Object.assign(
{
level: "info",
debug: false,
console: nodeConsole
},
this.options.infrastructureLogging
)
);
compiler.inputFileSystem = new CachedInputFileSystem(
new NodeJsInputFileSystem(),
60000
);
const inputFileSystem = compiler.inputFileSystem;
compiler.outputFileSystem = new NodeOutputFileSystem();
compiler.watchFileSystem = new NodeWatchFileSystem(
compiler.inputFileSystem
);
compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {
if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
});
}
}

这边有很多的 fileSystem,这边先不关注它们的区别。

1.2 监听文件变化

通过 watchFileSystem 的 watch 方法。在这个方法里,每次会拿出来旧的 watchpack 实例,并且生成新的 watchpack 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
watch(files, dirs, missing, startTime, options, callback, callbackUndelayed) {

...

const oldWatcher = this.watcher;// 保存旧的 watchpack 引用
this.watcher = new Watchpack(options);// 生成新的 watchpack

if (callbackUndelayed) {
this.watcher.once("change", callbackUndelayed);
}
const cachedFiles = files;
const cachedDirs = dirs;
this.watcher.once("aggregated", (changes, removals) => {

...

callback(// 这边的回调就是会触发 _invalidate 然后重新编译
null,
changes.filter(file => files.has(file)).sort(),
changes.filter(file => dirs.has(file)).sort(),
changes.filter(file => missing.has(file)).sort(),
times,
times,
removals
);
});

this.watcher.watch(// watch 方法看下面,主要是清理旧的文件监听事件,并且为新的文件添加监听事件
cachedFiles.concat(missing),
cachedDirs.concat(missing),
startTime
);

if (oldWatcher) {// 清除旧的 watchpack 监听事情
oldWatcher.close();
}
return {
close: () => {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
},
pause: () => {
if (this.watcher) {
this.watcher.pause();
}
},
getFileTimestamps: () => {
if (this.watcher) {
return objectToMap(this.watcher.getTimes());
} else {
return new Map();
}
},
getContextTimestamps: () => {
if (this.watcher) {
return objectToMap(this.watcher.getTimes());
} else {
return new Map();
}
}
};
}

这个就是上面调用的 this.watcher.watch,这边做的事情就使用 chokidar 为文件(夹)添加监听变化事件,并且把旧的文件(夹)监听事情取消。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Watchpack.prototype.watch = function watch(files, directories, startTime) {
this.paused = false;
var oldFileWatchers = this.fileWatchers;
var oldDirWatchers = this.dirWatchers;
this.fileWatchers = files.map(function(file) {
return this._fileWatcher(file, watcherManager.watchFile(file, this.watcherOptions, startTime));
}, this);
this.dirWatchers = directories.map(function(dir) {
return this._dirWatcher(dir, watcherManager.watchDirectory(dir, this.watcherOptions, startTime));
}, this);
oldFileWatchers.forEach(function(w) {
w.close();
}, this);
oldDirWatchers.forEach(function(w) {
w.close();
}, this);
};

经过上面这些方法添加的监听事件之后,只要触发文件变动就会走进重新编译的过程。

参考资料

从零实现webpack热更新HMR

https://webpack.js.org/configuration/dev-server/

webpack 中的 watch